5.09. ООП в Kotlin
## Объектно-ориентированное программирование в Kotlin
Объектно-ориентированное программирование (ООП) остаётся одной из ключевых парадигм разработки программного обеспечения на протяжении уже нескольких десятилетий, и Kotlin, как современный статически типизированный язык общего назначения, реализует её принципы с рядом уточнений, направленных на повышение выразительности, безопасности и удобства использования. В отличие от чисто функциональных или гибридных языков, где ООП может быть опциональным или второстепенным, в Kotlin объектная модель образует основу языковой архитектуры: даже базовые типы данных обёрнуты в объекты, а классы выступают как единственная структурная единица для инкапсуляции состояния и поведения.
Kotlin основан на той же фундаментальной системе, что используется в Java и других JVM-языках, однако вводит ряд синтаксических и семантических улучшений, устраняющих исторические недостатки, упрощающих типовой код и повышающих надёжность. Эти улучшения не нарушают совместимости с существующей Java-экосистемой, а, напротив, позволяют постепенно модернизировать legacy-код, сохраняя полную взаимодействуемость.
Классы и объекты
В Kotlin классы объявляются с помощью ключевого слова class, после которого следует идентификатор и, необязательно, список параметров первичного конструктора. Первичный конструктор — одна из наиболее заметных особенностей Kotlin: он интегрирован непосредственно в заголовок класса и позволяет объявлять свойства (val, var) и инициализировать их без необходимости писать отдельный конструкторный блок, как это делается в Java.
Пример:
class Person(val name: String, var age: Int) {
fun greet() {
println("Hello, my name is $name")
}
}
В этом объявлении val name и var age — полноценные свойства экземпляра: name неизменяемо (аналог final в Java), age — изменяемо. Значения передаются при создании объекта:
val person = Person("Alice", 30)
person.greet() // Вывод: Hello, my name is Alice
Синтаксис Person("Alice", 30) инициирует вызов первичного конструктора и создание нового экземпляра. В отличие от Java, где каждая строка вида new X() сопровождается явным указанием оператора new, Kotlin использует более лаконичную запись, при этом сохраняя строгую типизацию: компилятор проверяет соответствие типов аргументов, а также разрешает перегрузку конструкторов через вторичные конструкторы или factory-функции.
Каждый класс в Kotlin по умолчанию закрыт для наследования — это принципиальное изменение по сравнению с Java, где классы открыты по умолчанию. Такое решение мотивировано практиками проектирования: большинство классов не предназначено для расширения, и их непреднамеренное наследование может привести к нарушению инвариантов и трудноуловимым ошибкам. Чтобы разрешить наследование, класс должен быть явно объявлен как open:
open class Animal(val name: String)
class Cat(name: String) : Animal(name)
Здесь Cat наследует от Animal, передавая имя через конструктор базового класса. Обратите внимание: синтаксис : заменяет Java-овый extends, а вызов конструктора родителя включён прямо в объявление наследования. Если базовый класс содержит первичный конструктор, его аргументы должны быть переданы непосредственно в месте объявления класса-наследника — это исключает ошибки, связанные с отложенной инициализацией или пропущенными вызовами super().
Наследование в Kotlin строго одноуровневое по базовым типам: язык не поддерживает множественное наследование классов, что сохраняет стабильность иерархии типов. Однако для реализации полиморфного поведения Kotlin предоставляет интерфейсы — они могут включать реализацию по умолчанию, что делает их мощным средством композиции поведения без нарушения принципа единственной цепочки наследования.
Инкапсуляция и управление видимостью
Инкапсуляция — механизм, обеспечивающий сокрытие внутреннего состояния объекта и предоставление доступа к нему только через строго определённые методы. В Kotlin эта концепция реализована через систему модификаторов видимости и свойств с геттерами и сеттерами.
Модификаторы доступа в Kotlin:
public— по умолчанию, доступно из любого места;private— доступно только внутри объявляющего класса или файла (для top-level элементов);protected— доступно внутри объявляющего класса и его подклассов;internal— доступно в пределах модуля компиляции (обычно — одного Gradle/Maven-проекта).
Пример с private:
class User(private val login: String) {
private var _passwordHash: String? = null
fun setPassword(password: String) {
_passwordHash = hash(password)
}
private fun hash(s: String): String = /* ... */
}
Здесь login неизменяем и недоступен извне, _passwordHash — внутреннее представление, изменяемое только через метод setPassword. Отсутствие публичного сеттера гарантирует, что пароль всегда проходит хеширование и не может быть установлен напрямую.
Важной особенностью Kotlin является то, что свойства (val/var) не обязаны соответствовать физическим полям. Компилятор автоматически генерирует байт-код с приватными полями и публичными методами-аксессорами (getLogin(), getPasswordHash() и т.д.), совместимыми с JavaBeans-спецификацией. При этом разработчик может переопределить поведение геттера или сеттера без изменения сигнатуры:
class Counter {
var count: Int = 0
private set // сеттер приватный, геттер — по умолчанию public
fun increment() { count++ }
}
Такой подход позволяет сохранить полную совместимость с Java-библиотеками и фреймворками, ожидая наличие геттеров и сеттеров, при этом оставаясь выразительным и безопасным на уровне исходного кода.
Полиморфизм и динамическое связывание
Полиморфизм в Kotlin реализуется через наследование и переопределение методов. Метод в базовом классе должен быть объявлен как open, чтобы его можно было переопределить, а в подклассе — с ключевым словом override:
open class Shape {
open fun draw() {
println("Generic shape")
}
}
class Circle : Shape() {
override fun draw() {
println("Drawing a circle")
}
}
Вызов draw() на ссылке типа Shape, указывающей на объект Circle, приведёт к выполнению переопределённой версии — так работает динамическое связывание (late binding). Эта модель полностью совместима с JVM-механизмом виртуальных вызовов и позволяет строить гибкие иерархии.
Kotlin также поддерживает абстрактные классы и методы (abstract class, abstract fun), а также интерфейсы с реализацией по умолчанию, что расширяет возможности композиции. Например:
interface Drawable {
fun draw()
fun render() {
println("Default rendering logic")
draw()
}
}
class Rectangle : Drawable {
override fun draw() {
println("Drawing rectangle")
}
}
Метод render() может быть унаследован без изменения, а draw() — обязан быть реализован. Такой подход уменьшает дублирование и позволяет вводить новые методы в интерфейсы без разрушения совместимости.
Data-классы
Особое внимание в Kotlin уделено классам, основная цель которых — хранение данных. В таких случаях разработчик обычно ожидает наличие стандартных методов: сравнения (equals()), хеширования (hashCode()), строкового представления (toString()), копирования (copy()). В Java их приходится писать вручную (или генерировать IDE), что порождает шаблонный, многострочный код, подверженный ошибкам.
Kotlin решает эту проблему через ключевое слово data:
data class User(val id: Int, val name: String)
Для такого класса компилятор автоматически генерирует:
equals(other: Any?)иhashCode(), основанные на всех свойствах, объявленных в первичном конструкторе;toString(), возвращающий читаемое представление видаUser(id=1, name=Alice);copy(...), позволяющий создать копию объекта с изменением отдельных полей, например:user.copy(name = "Bob").
Важно: генерируемые методы учитывают только свойства из первичного конструктора. Поля, объявленные внутри тела класса, в семантику equals и hashCode не входят — это осознанное ограничение, обеспечивающее предсказуемость и соответствие интуитивным ожиданиям. Data-классы также деструктурируются в выражениях вида val (id, name) = user, что упрощает работу с кортежами и возвратом нескольких значений из функций.
Data-классы не обязаны быть immutable, но практика показывает, что их чаще всего объявляют с val, что способствует написанию более надёжного, потокобезопасного кода.
Статические члены и компаньонные объекты
Kotlin не имеет понятия «статических методов» или «статических полей» в том виде, как они существуют в Java. Вместо этого вводится концепция компаньонных объектов — объектов, принадлежащих классу и инициализируемых вместе с ним:
class Database {
companion object {
const val VERSION = "2.1"
fun connect(url: String): Connection { ... }
}
}
// Использование:
val version = Database.VERSION
val conn = Database.connect("jdbc:...")
companion object — это полноценный singleton-объект, вложенный в класс. Он может реализовывать интерфейсы, наследоваться от других классов, содержать свойства и методы. При компиляции в байт-код JVM такие члены преобразуются в статические поля и методы, что обеспечивает полную совместимость с Java.
Для констант, известных на этапе компиляции, предпочтительно использовать const val внутри компаньонного объекта — это позволяет компилятору встраивать их значение напрямую в клиентский код (как static final в Java), повышая производительность.
Функции высшего порядка, лямбды и замыкания
Хотя Kotlin — не функциональный язык в строгом смысле, он предоставляет мощные средства для функционального стиля программирования, включая функции высшего порядка, лямбда-выражения и замыкания. Это не противоречит ООП — наоборот, расширяет его: функции становятся полноценными объектами первого класса, которые можно передавать, хранить и возвращать.
Лямбда-выражение — это анонимная функция, заключённая в фигурные скобки:
val sum: (Int, Int) -> Int = { a, b -> a + b }
Здесь (Int, Int) -> Int — тип функции: два целочисленных аргумента, возвращающая Int. Выражение { a, b -> a + b } создаёт экземпляр Function2<Int, Int, Int>, который совместим с Java-интерфейсом java.util.function.BiFunction.
Функция высшего порядка принимает другую функцию в качестве параметра:
fun process(n: Int, block: (Int) -> Unit) {
block(n)
}
process(5) { println(it) } // "it" — неявный параметр по умолчанию
Вызов process(5) { ... } использует синтаксический сахар — если последний аргумент является лямбдой, её можно вынести за скобки. Если лямбда — единственный аргумент, скобки вообще опускаются. Это делает код похожим на встроенные управляющие конструкции, несмотря на то, что process — обычная функция.
Kotlin поддерживает замыкания: лямбда захватывает переменные из окружающей области видимости, и они остаются доступными даже после выхода из функции, в которой были объявлены. При этом захваченные val-переменные используются по значению, а var — по ссылке (через обёртку Ref<T>), что позволяет им сохранять изменённое состояние между вызовами.
Такой подход позволяет элегантно реализовывать стратегии, обработчики событий, асинхронные цепочки и другие паттерны, где поведение параметризуется кодом, а не только данными.
Расширения
Одной из наиболее выразительных возможностей Kotlin является механизм расширений (extensions) — способ добавления новых функций и свойств к существующим классам без изменения их исходного кода и без наследования. Это позволяет обогащать сторонние или стандартные типы поведением, специфичным для конкретного контекста, сохраняя при этом чистоту архитектуры и избегая «разбухания» базовых классов.
Синтаксис расширения прост: имя получателя указывается перед именем функции как префикс:
fun String.addExclamation(): String {
return this + "!"
}
// Использование:
println("Hello".addExclamation()) // Hello!
Здесь String.addExclamation() — это расширяющая функция, доступная для любого экземпляра String. Внутри тела функции this ссылается на получатель (receiver), то есть на строку, для которой вызван метод.
Важно понимать: расширения не модифицируют исходный класс. Они компилируются в статические вспомогательные методы, принимающие получатель в качестве первого параметра. На уровне JVM код str.addExclamation() превращается в вызов ExtensionsKt.addExclamation(str). Это означает:
- расширения не могут получить доступ к
privateилиprotectedчленам класса; - они не участвуют в полиморфизме — выбор конкретной реализации происходит статически, на этапе компиляции, а не динамически, как при переопределении методов;
- если в классе уже существует метод с такой же сигнатурой, он имеет приоритет над расширением.
Аналогично можно объявлять расширяющие свойства:
val String.isPalindrome: Boolean
get() = this.lowercase() == this.lowercase().reversed()
Обратите внимание: расширяющее свойство не хранит состояние — оно всегда реализуется через геттер (и, при необходимости, сеттер), поскольку в классе получателя физически нет соответствующего поля.
Расширения особенно эффективны в связке с обобщёнными типами, функциями высшего порядка и специализированными DSL. Например, библиотека kotlinx.html строит HTML-разметку с помощью расширений, превращая вызовы в декларативные блоки вида div { h1 { +"Title" } }. Это не синтаксический сахар компилятора — это полноценный механизм, реализованный средствами языка.
Защита от ошибок, связанных с отсутствием значения
Одной из наиболее значимых и практически полезных особенностей Kotlin является встроенная система типов с явной поддержкой отсутствия значения. Понятие null не устранено (так как необходимо для совместимости с JVM и внешними API), но его использование жёстко регламентировано: тип, допускающий значение null, должен быть объявлен явно с помощью суффикса ?.
Пример:
var name: String = "Alice" // не может быть null
var nullableName: String? = null // может быть null
Попытка присвоить null переменной типа String приведёт к ошибке компиляции. Аналогично, вызов метода напрямую на nullableName (например, nullableName.length) запрещён — компилятор потребует обработки возможного отсутствия значения.
Для безопасной работы с nullable-типами Kotlin предоставляет несколько механизмов:
-
Проверка через
if:if (nullableName != null) {
println(nullableName.length) // внутри ветки nullableName имеет тип String
} -
Оператор безопасного вызова
?.:val length = nullableName?.length // тип Int? -
Оператор Элвиса
?:для задания значения по умолчанию:val name = nullableName ?: "Anonymous" -
Оператор утверждения
!!— явное подавление проверки (используется редко, только при уверенности в ненулевом значении):val length = nullableName!!.length // бросит NPE, если null
Система работает на уровне типов и анализируется статически. Это позволяет полностью исключить NullPointerException в коде, написанном на Kotlin, при условии корректного объявления типов. Исключения возможны только при взаимодействии с Java-кодом, где аннотации @Nullable и @NotNull помогают компилятору Kotlin восстановить информацию о nullability.
Такой подход кардинально повышает надёжность: ошибка проектирования, допускающая неконтролируемое распространение null-значений, выявляется на этапе компиляции, а не во время выполнения.
Корутины
Асинхронное и параллельное программирование — одна из самых сложных тем в разработке. Традиционные подходы (коллбэки, Future, ExecutorService) порождают сложный, трудночитаемый и подверженный ошибкам код. Kotlin предлагает единый, лёгкий и выразительный механизм — корутины (coroutines).
Корутина — это лёгковесная единица выполнения, управляемая пользовательским кодом, а не планировщиком ОС. В отличие от потоков, корутины не привязаны к конкретному системному потоку: они могут приостанавливаться (suspend), освобождая поток для выполнения другой работы, и возобновляться позже — возможно, уже в другом потоке. При этом синтаксис остаётся последовательным, без вложенности коллбэков.
Основные компоненты:
- ключевое слово
suspend, помечающее функцию, которая может приостанавливаться; - встроенные функции
launch,async,runBlockingдля запуска корутин; - диспетчеры (
Dispatchers.IO,Dispatchers.Default,Dispatchers.Main) для управления контекстом выполнения.
Пример:
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
Здесь runBlocking создаёт корутинную область и блокирует текущий поток до завершения всех дочерних корутин. launch запускает новую корутину, которая выполняется параллельно. Вызов delay(1000L) — это приостановка, а не блокировка потока: в течение этой секунды поток может выполнять другие задачи.
Функция delay объявлена как suspend, что означает: её можно вызывать только из других suspend-функций или корутинных блоков. Это обеспечивает статическую проверку корректности асинхронного кода: невозможно случайно вызвать приостанавливающую операцию в синхронном контексте.
Корутины не являются частью языка в строгом смысле — они реализованы в библиотеке kotlinx.coroutines, но настолько тесно интегрированы с языком (через suspend), что воспринимаются как встроенные. Механизм поддерживает структурированную конкурентность: корутины образуют иерархию, и отмена родительской корутины автоматически отменяет всех потомков, предотвращая утечки ресурсов.
Аннотации
Kotlin полностью поддерживает аннотации — метаданные, добавляемые к объявлениям классов, функций, свойств, параметров и других элементов. Аннотации не влияют на выполнение программы напрямую, но используются компилятором, фреймворками и инструментами для генерации кода, проверок, сериализации и других задач.
Синтаксис аналогичен Java:
@Deprecated("Use newFunction() instead", ReplaceWith("newFunction()"))
fun oldFunction() { ... }
@JvmStatic
fun utility() { ... }
Некоторые аннотации специфичны для Kotlin и влияют на генерацию JVM-байт-кода:
@JvmStatic— размещает метод в классе какstatic, а не в компаньонном объекте;@JvmOverloads— генерирует перегруженные Java-методы для параметров по умолчанию;@JvmField— преобразует свойство в публичное поле без геттера и сеттера;@JvmName— задаёт альтернативное имя для Java-вызова.
Аннотации могут иметь параметры, включая классы, массивы и лямбды. Они поддерживают удержание (Retention), цель (Target) и наследование, как и в Java. Благодаря полной совместимости, Kotlin-код может использовать аннотации из Spring, JPA, JUnit и других Java-фреймворков без ограничений.
Перегрузка операторов
Kotlin позволяет переопределять поведение арифметических, логических и других операторов для пользовательских типов — но только для заранее определённого набора. Это строго регламентированная функциональность: каждый оператор связан с конкретным именем функции (например, + — с plus, == — с equals, [i] — с get(i)), и компилятор заменяет операторный вызов на вызов соответствующего метода.
Пример:
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
}
val p1 = Point(1, 2)
val p2 = Point(3, 4)
val p3 = p1 + p2 // эквивалентно p1.plus(p2)
Ключевое слово operator обязательно — оно сигнализирует, что метод предназначен для поддержки синтаксиса операторов. Без него запись p1 + p2 не компилируется.
Поддерживаемые операторы включают:
- арифметические:
+,-,*,/,%,++,--; - операторы присваивания:
+=,-=и т.д.; - сравнения:
==,!=,<,<=,>,>=(черезequals,compareTo); - индексные операции:
a[i],a[i] = v; - вызов как функции:
a(); - диапазоны:
..,until.
Важно: семантика операторов должна соответствовать ожиданиям. Например, == всегда вызывает equals, и его нельзя переопределить иначе — это гарантирует, что равенство остаётся рефлексивным, симметричным и транзитивным. Перегрузка == вручную невозможна: вместо этого переопределяется equals.
Такой подход обеспечивает выразительность без потери предсказуемости: операторы остаются просто синтаксическим сахаром над именованными методами, и их поведение можно всегда проследить в исходном коде.
Взаимодействие с Java
Kotlin разрабатывался с приоритетом на полный bidirectional interoperability с Java. Это не просто возможность вызывать Java из Kotlin — это гарантия того, что:
- любой Kotlin-класс может быть использован из Java без обёрток;
- любой Java-класс доступен в Kotlin с улучшенным синтаксисом;
- артефакты (JAR-файлы) Kotlin и Java могут свободно смешиваться в одном проекте.
Компилятор Kotlin генерирует стандартный JVM-байт-код, совместимый с Java 6 и выше. Все свойства преобразуются в private-поля с публичными getter/setter-методами, соответствующими соглашениям JavaBeans. Data-классы генерируют equals, hashCode, toString в том же формате, что и IDE-генераторы в Java. Компаньонные объекты становятся вложенными статическими классами с именем Companion, а их члены — статическими методами и полями.
Обратная совместимость также обеспечена: из Java можно вызывать Kotlin-код, используя сгенерированные методы напрямую:
// Java
User user = new User(1, "Alice");
String s = user.toString(); // вызывает сгенерированный toString()
User copy = user.copy(2, "Bob"); // метод copy доступен как copy(int, String)
Для улучшения взаимодействия Kotlin предоставляет специальные аннотации (@JvmName, @JvmStatic, @JvmOverloads), а также распознаёт Java-аннотации @Nullable и @NotNull (из javax.annotation, androidx.annotation, org.jetbrains.annotations), чтобы корректно обрабатывать nullability.
Это позволяет постепенно мигрировать проекты: отдельные модули или классы переписываются на Kotlin, в то время как остальная часть остаётся на Java — без необходимости переписывать всю систему целиком.
Делегирование
Kotlin реализует фундаментальный принцип объектно-ориентированного проектирования — предпочитать композицию наследованию — не только как рекомендацию, но как встроенную языковую конструкцию: ключевое слово by позволяет реализовать интерфейс через делегирование за одну строку.
Идея проста: если класс декларирует реализацию интерфейса, но не содержит собственной логики для его методов, он может передать эту ответственность другому объекту — делегату. При этом компилятор автоматически создаёт реализацию всех методов интерфейса, перенаправляя вызовы делегату.
Пример:
interface Printer {
fun print(message: String)
}
class ConsolePrinter : Printer {
override fun print(message: String) = println(message)
}
class LoggingPrinter(printer: Printer) : Printer by printer {
// Весь интерфейс Printer реализован через printer
}
Здесь LoggingPrinter реализует Printer, но не содержит ни одного метода. Компилятор генерирует:
// Эквивалент на Java:
public final class LoggingPrinter implements Printer {
private final Printer printer;
public LoggingPrinter(Printer printer) {
this.printer = printer;
}
public void print(String message) {
this.printer.print(message);
}
}
Такой подход исключает дублирование кода, упрощает сопровождение и повышает гибкость: логика может быть заменена в runtime, если делегат объявлен как var (хотя по умолчанию — val).
Делегированные свойства
Ключевое слово by также применяется к свойствам через механизм делегированных свойств (delegated properties). Он позволяет вынести логику хранения и доступа к значению в отдельный объект-делегат, реализующий стандартный интерфейс ReadWriteProperty или ReadOnlyProperty.
Стандартная библиотека Kotlin предоставляет несколько встроенных делегатов:
-
lazy— отложенная инициализация (однократная, потокобезопасная по умолчанию):val config by lazy { loadConfig() } -
Delegates.observable— наблюдение за изменениями:var name: String by Delegates.observable("Anonymous") { _, old, new ->
println("Name changed from $old to $new")
} -
Delegates.vetoable— возможность отклонить изменение на основе условия.
Делегированные свойства особенно эффективны в связке с фреймворками: например, в Android by viewBinding() или в Ktor by inject(). При этом семантика остаётся прозрачной: x = 5 и x внутри тела функции работают так же, как с обычным свойством, несмотря на то, что доступ контролируется внешним кодом.
Делегаты не нарушают инкапсуляцию — они не получают прямого доступа к внутренним данным класса, а взаимодействуют только через контракт интерфейса.
Sealed-классы
Одной из центральных проблем при работе с иерархиями типов является необходимость обработки всех возможных подтипов. В традиционных ООП-языках instanceof-проверки или switch по типу не являются исчерпывающими: добавление нового подкласса не вызывает ошибок компиляции, что приводит к неполному покрытию логики и runtime-сбоям.
Kotlin решает эту проблему через sealed-классы (запечатанные классы) — специальный вид абстрактных классов, для которых множество прямых подклассов фиксировано на этапе компиляции и должно быть объявлено в том же файле.
Пример:
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
Здесь Result не может иметь подклассов вне этого файла. Это позволяет компилятору проверять полноту при сопоставлении с образцом (when):
fun handle(result: Result) = when (result) {
is Result.Success -> println("Data: ${result.data}")
is Result.Error -> println("Error: ${result.message}")
Result.Loading -> println("Loading...")
// Если добавить новый подкласс без добавления ветки — ошибка компиляции
}
Ключевые особенности:
- sealed-классы неявно
open, но могут наследоваться только локально; - подклассы могут быть как классами, так и объектами (singleton’ами);
- ветви
whenне требуютelse, если перечислены все подтипы; whenс sealed-типом возвращает значение, что делает его полноценным выражением.
Sealed-классы особенно ценны при моделировании состояний (например, в архитектурах MVI), сетевых ответов, парсеров и конечных автоматов — везде, где требуется гарантия исчерпывающей обработки вариантов.
Внутренние и вложенные классы
Kotlin различает два вида классов, объявленных внутри другого класса:
-
Вложенные классы (
nested class) — по умолчанию. Они не имеют доступа к экземпляру внешнего класса и ведут себя как статические вложенные классы в Java.class Outer {
private val x = 10
class Nested {
fun foo() = "Independent" // нет доступа к x
}
} -
Внутренние классы (
inner class) — явно помеченные ключевым словомinner. Они содержат неявную ссылку на экземпляр внешнего класса и могут обращаться к его членам.class Outer {
private val x = 10
inner class Inner {
fun foo() = "x = $x" // доступ к x возможен
}
}
val outer = Outer()
val inner = outer.Inner() // создание требует экземпляра Outer
На уровне JVM inner class компилируется в отдельный класс с синтетическим полем this$0, ссылающимся на внешний объект — точно так же, как и в Java.
Выбор между nested и inner — архитектурное решение:
nestedиспользуется, когда вложенная сущность логически принадлежит пространству имён внешнего класса, но не зависит от его состояния (например, вспомогательные builder’ы, DTO, специализированные исключения);inner— когда требуется доступ к закрытым или защищённым членам внешнего класса, и экземпляр внутреннего класса семантически не существует без внешнего (например, итераторы, обработчики событий, делегаты представления).
Kotlin не позволяет объявлять внутренние классы внутри интерфейсов или объектов — только внутри классов, что исключает неоднозначность.
Объектные выражения и компаньоны как реализация паттернов
Помимо companion object, Kotlin поддерживает объектные выражения — анонимные реализации классов или интерфейсов, похожие на Java-анонимные классы, но с расширенными возможностями.
Синтаксис:
val comparator = object : Comparator<String> {
override fun compare(a: String, b: String): Int {
return a.length - b.length
}
}
Объектное выражение создаёт новый анонимный класс и единственный экземпляр этого класса (singleton в локальной области). В отличие от Java, такие выражения могут:
-
наследовать от класса и реализовывать несколько интерфейсов одновременно:
object : BaseClass(), InterfaceA, InterfaceB { ... } -
содержать собственные свойства и методы, недоступные извне, но используемые внутри:
val handler = object {
private var count = 0
fun onClick() {
count++
println("Clicked $count times")
}
}
handler.onClick() // допустимо; handler имеет тип этого анонимного объекта
Эта возможность позволяет реализовывать паттерн одиночка (Singleton) без boilerplate-кода private constructor + getInstance(): просто объявляется object, и его экземпляр создаётся lazily и потокобезопасно при первом доступе.
Пример использования в качестве фабрики:
interface ConnectionFactory {
fun create(): Connection
}
object DatabaseConnectionFactory : ConnectionFactory {
override fun create(): Connection {
return DriverManager.getConnection(url, user, pass)
}
}
Здесь DatabaseConnectionFactory — глобально доступный, лениво инициализированный singleton, реализующий интерфейс. Никакого дополнительного кода для управления жизненным циклом не требуется.